🎖️GitЯра🎖️
Commit 7a42a34dbcd11b4800cc9c6130b3f4d87ab945df
Parents : 3559575
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-11T10:49:48-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-11T17:49:48Z
feat(settings): export the node database as JSON (#6610)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changes
12 files changed, 648 insertions(+), 0 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 73b81f367b..65f96685b6 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -572,9 +572,11 @@ exchange_position
expand_chart
expanded
expires
+### EXPORT ###
export_configuration
export_data_csv
export_gpx
+export_node_db
export_tak_data_package
external_notification
external_notification_config
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index 5e7cf27491..ef45d55ff0 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -18,6 +18,7 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.koin)
+ alias(libs.plugins.meshtastic.kotlinx.serialization)
}
kotlin {
@@ -37,6 +38,7 @@ kotlin {
implementation(libs.okio)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.serialization.json.okio)
}
commonTest.dependencies { implementation(projects.core.testing) }
}
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt
new file mode 100644
index 0000000000..1b39c88e65
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.domain.usecase.settings
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.okio.encodeToBufferedSink
+import okio.BufferedSink
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.GeoConstants
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.proto.AirQualityMetrics
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.EnvironmentMetrics
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.Paxcount
+import org.meshtastic.proto.PowerMetrics
+import kotlin.time.Instant
+import org.meshtastic.proto.Position as WirePosition
+
+/**
+ * Exports the currently selected device's node database as a JSON [NodeDatabaseExport] document. Absent readings are
+ * omitted rather than written as 0/sentinel values, so 0 dB SNR or 0 °C stay distinguishable from "no reading".
+ */
+@Single
+class ExportNodeDatabaseUseCase(private val nodeRepository: NodeRepository) {
+
+ companion object {
+ private const val SCHEMA_VERSION = 1
+ }
+
+ private val json = Json {
+ prettyPrint = true
+ explicitNulls = false
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend operator fun invoke(sink: BufferedSink) {
+ val nodes =
+ nodeRepository
+ .getNodeDbSnapshot()
+ .values
+ .sortedWith(compareByDescending<Node> { it.lastHeard }.thenBy { it.num.toUInt() })
+ .map { it.toExport() }
+ val export =
+ NodeDatabaseExport(
+ schemaVersion = SCHEMA_VERSION,
+ exportedAt = Instant.fromEpochMilliseconds(nowMillis).toString(),
+ myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum?.toUnsignedLong(),
+ nodes = nodes,
+ )
+ json.encodeToBufferedSink(export, sink)
+ sink.flush()
+ }
+}
+
+private fun Int.toUnsignedLong(): Long = toUInt().toLong()
+
+private fun Node.toExport(): NodeExport = NodeExport(
+ num = num.toUnsignedLong(),
+ id = user.id.takeIf { it.isNotEmpty() },
+ longName = user.long_name.takeIf { it.isNotEmpty() },
+ shortName = user.short_name.takeIf { it.isNotEmpty() },
+ hwModel = user.hw_model.takeIf { it != HardwareModel.UNSET }?.name,
+ role = user.role.name,
+ isLicensed = user.is_licensed,
+ isUnmessagable = user.is_unmessagable,
+ publicKey = (publicKey ?: user.public_key).takeIf { it.size > 0 }?.base64(),
+ signsPackets = signsPackets,
+ manuallyVerified = manuallyVerified,
+ lastHeard = lastHeard.takeIf { it != 0 }?.toLong(),
+ snr = snrOrNull,
+ rssi = rssiOrNull,
+ channel = channel,
+ viaMqtt = viaMqtt,
+ hopsAway = hopsAway.takeIf { it >= 0 },
+ lastTransport = MeshPacket.TransportMechanism.fromValue(lastTransport)?.takeIf { lastTransport != 0 }?.name,
+ isFavorite = isFavorite,
+ isIgnored = isIgnored,
+ isMuted = isMuted,
+ nodeStatus = nodeStatus?.takeIf { it.isNotEmpty() },
+ notes = notes.takeIf { it.isNotEmpty() },
+ position = position.toExport(),
+ deviceMetrics = deviceMetrics.toExport(),
+ environmentMetrics = environmentMetrics.toExport(),
+ powerMetrics = powerMetrics.toExport(),
+ powerChannelLabels = powerChannelLabels.takeIf { it.isNotEmpty() },
+ airQualityMetrics = airQualityMetrics.toExport(),
+ paxcounter = paxcounter.toExport(),
+ metadata = metadata?.toExport(),
+)
+
+private fun WirePosition.toExport(): PositionExport? = PositionExport(
+ latitude = latitude_i?.times(GeoConstants.DEG_D),
+ longitude = longitude_i?.times(GeoConstants.DEG_D),
+ altitude = altitude,
+ time = time.takeIf { it != 0 }?.toLong(),
+ locationSource = location_source.takeIf { it != WirePosition.LocSource.LOC_UNSET }?.name,
+ groundSpeed = ground_speed,
+ groundTrack = ground_track,
+ satsInView = sats_in_view.takeIf { it != 0 },
+ precisionBits = precision_bits.takeIf { it != 0 },
+)
+ .takeUnless { it == PositionExport() }
+
+private fun DeviceMetrics.toExport(): DeviceMetricsExport? = DeviceMetricsExport(
+ batteryLevel = battery_level,
+ voltage = voltage,
+ channelUtilization = channel_utilization,
+ airUtilTx = air_util_tx,
+ uptimeSeconds = uptime_seconds,
+)
+ .takeUnless { it == DeviceMetricsExport() }
+
+private fun EnvironmentMetrics.toExport(): EnvironmentMetricsExport? = EnvironmentMetricsExport(
+ temperature = temperature,
+ relativeHumidity = relative_humidity,
+ barometricPressure = barometric_pressure,
+ gasResistance = gas_resistance,
+ voltage = voltage,
+ current = current,
+ iaq = iaq,
+ distance = distance,
+ lux = lux,
+ whiteLux = white_lux,
+ irLux = ir_lux,
+ uvLux = uv_lux,
+ windDirection = wind_direction,
+ windSpeed = wind_speed,
+ weight = weight,
+ windGust = wind_gust,
+ windLull = wind_lull,
+ radiation = radiation,
+ rainfall1h = rainfall_1h,
+ rainfall24h = rainfall_24h,
+ soilMoisture = soil_moisture,
+ soilTemperature = soil_temperature,
+ oneWireTemperature = one_wire_temperature.takeIf { it.isNotEmpty() },
+)
+ .takeUnless { it == EnvironmentMetricsExport() }
+
+private fun PowerMetrics.toExport(): PowerMetricsExport? = PowerMetricsExport(
+ ch1Voltage = ch1_voltage,
+ ch1Current = ch1_current,
+ ch2Voltage = ch2_voltage,
+ ch2Current = ch2_current,
+ ch3Voltage = ch3_voltage,
+ ch3Current = ch3_current,
+ ch4Voltage = ch4_voltage,
+ ch4Current = ch4_current,
+ ch5Voltage = ch5_voltage,
+ ch5Current = ch5_current,
+ ch6Voltage = ch6_voltage,
+ ch6Current = ch6_current,
+ ch7Voltage = ch7_voltage,
+ ch7Current = ch7_current,
+ ch8Voltage = ch8_voltage,
+ ch8Current = ch8_current,
+)
+ .takeUnless { it == PowerMetricsExport() }
+
+private fun AirQualityMetrics.toExport(): AirQualityMetricsExport? = AirQualityMetricsExport(
+ pm10Standard = pm10_standard,
+ pm25Standard = pm25_standard,
+ pm100Standard = pm100_standard,
+ pm10Environmental = pm10_environmental,
+ pm25Environmental = pm25_environmental,
+ pm100Environmental = pm100_environmental,
+ particles03um = particles_03um,
+ particles05um = particles_05um,
+ particles10um = particles_10um,
+ particles25um = particles_25um,
+ particles50um = particles_50um,
+ particles100um = particles_100um,
+ co2 = co2,
+ co2Temperature = co2_temperature,
+ co2Humidity = co2_humidity,
+ formFormaldehyde = form_formaldehyde,
+ formHumidity = form_humidity,
+ formTemperature = form_temperature,
+ pm40Standard = pm40_standard,
+ particles40um = particles_40um,
+ pmTemperature = pm_temperature,
+ pmHumidity = pm_humidity,
+ pmVocIdx = pm_voc_idx,
+ pmNoxIdx = pm_nox_idx,
+ particlesTps = particles_tps,
+)
+ .takeUnless { it == AirQualityMetricsExport() }
+
+private fun Paxcount.toExport(): PaxcountExport? =
+ PaxcountExport(wifi = wifi, ble = ble, uptime = uptime).takeUnless { it == PaxcountExport() }
+
+private fun DeviceMetadata.toExport(): DeviceMetadataExport = DeviceMetadataExport(
+ firmwareVersion = firmware_version.takeIf { it.isNotEmpty() },
+ deviceStateVersion = device_state_version.takeIf { it != 0 },
+ canShutdown = canShutdown,
+ hasWifi = hasWifi,
+ hasBluetooth = hasBluetooth,
+ hasEthernet = hasEthernet,
+ role = role.name,
+ positionFlags = position_flags.takeIf { it != 0 },
+ hwModel = hw_model.takeIf { it != HardwareModel.UNSET }?.name,
+ hasRemoteHardware = hasRemoteHardware,
+ hasPKC = hasPKC,
+ excludedModules = excluded_modules.takeIf { it != 0 },
+ hasXeddsa = has_xeddsa,
+)
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt
new file mode 100644
index 0000000000..613273e750
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.domain.usecase.settings
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Root document written by [ExportNodeDatabaseUseCase]. Absent fields are omitted from the JSON rather than emitted as
+ * null/0 sentinels, so consumers can distinguish "no reading" from a genuine zero.
+ */
+@Serializable
+data class NodeDatabaseExport(
+ val schemaVersion: Int,
+ /** ISO-8601 UTC timestamp of when the export was produced. */
+ val exportedAt: String,
+ /** Unsigned node number of the local device, when known. */
+ val myNodeNum: Long? = null,
+ val nodes: List<NodeExport>,
+)
+
+@Serializable
+data class NodeExport(
+ /** Unsigned 32-bit node number. */
+ val num: Long,
+ /** Hex user id, e.g. "!a1b2c3d4". */
+ val id: String? = null,
+ val longName: String? = null,
+ val shortName: String? = null,
+ val hwModel: String? = null,
+ val role: String? = null,
+ val isLicensed: Boolean = false,
+ val isUnmessagable: Boolean? = null,
+ /** Base64-encoded Curve25519 public key. */
+ val publicKey: String? = null,
+ val signsPackets: Boolean = false,
+ val manuallyVerified: Boolean = false,
+ /** Epoch seconds of the last packet heard from this node. */
+ val lastHeard: Long? = null,
+ val snr: Float? = null,
+ val rssi: Int? = null,
+ val channel: Int = 0,
+ val viaMqtt: Boolean = false,
+ val hopsAway: Int? = null,
+ val lastTransport: String? = null,
+ val isFavorite: Boolean = false,
+ val isIgnored: Boolean = false,
+ val isMuted: Boolean = false,
+ val nodeStatus: String? = null,
+ val notes: String? = null,
+ val position: PositionExport? = null,
+ val deviceMetrics: DeviceMetricsExport? = null,
+ val environmentMetrics: EnvironmentMetricsExport? = null,
+ val powerMetrics: PowerMetricsExport? = null,
+ val powerChannelLabels: List<String>? = null,
+ val airQualityMetrics: AirQualityMetricsExport? = null,
+ val paxcounter: PaxcountExport? = null,
+ val metadata: DeviceMetadataExport? = null,
+)
+
+@Serializable
+data class PositionExport(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val altitude: Int? = null,
+ /** Epoch seconds of the position fix. */
+ val time: Long? = null,
+ val locationSource: String? = null,
+ val groundSpeed: Int? = null,
+ val groundTrack: Int? = null,
+ val satsInView: Int? = null,
+ val precisionBits: Int? = null,
+)
+
+@Serializable
+data class DeviceMetricsExport(
+ val batteryLevel: Int? = null,
+ val voltage: Float? = null,
+ val channelUtilization: Float? = null,
+ val airUtilTx: Float? = null,
+ val uptimeSeconds: Int? = null,
+)
+
+@Serializable
+data class EnvironmentMetricsExport(
+ val temperature: Float? = null,
+ val relativeHumidity: Float? = null,
+ val barometricPressure: Float? = null,
+ val gasResistance: Float? = null,
+ val voltage: Float? = null,
+ val current: Float? = null,
+ val iaq: Int? = null,
+ val distance: Float? = null,
+ val lux: Float? = null,
+ val whiteLux: Float? = null,
+ val irLux: Float? = null,
+ val uvLux: Float? = null,
+ val windDirection: Int? = null,
+ val windSpeed: Float? = null,
+ val weight: Float? = null,
+ val windGust: Float? = null,
+ val windLull: Float? = null,
+ val radiation: Float? = null,
+ val rainfall1h: Float? = null,
+ val rainfall24h: Float? = null,
+ val soilMoisture: Int? = null,
+ val soilTemperature: Float? = null,
+ val oneWireTemperature: List<Float>? = null,
+)
+
+@Serializable
+data class PowerMetricsExport(
+ val ch1Voltage: Float? = null,
+ val ch1Current: Float? = null,
+ val ch2Voltage: Float? = null,
+ val ch2Current: Float? = null,
+ val ch3Voltage: Float? = null,
+ val ch3Current: Float? = null,
+ val ch4Voltage: Float? = null,
+ val ch4Current: Float? = null,
+ val ch5Voltage: Float? = null,
+ val ch5Current: Float? = null,
+ val ch6Voltage: Float? = null,
+ val ch6Current: Float? = null,
+ val ch7Voltage: Float? = null,
+ val ch7Current: Float? = null,
+ val ch8Voltage: Float? = null,
+ val ch8Current: Float? = null,
+)
+
+@Serializable
+data class AirQualityMetricsExport(
+ val pm10Standard: Int? = null,
+ val pm25Standard: Int? = null,
+ val pm100Standard: Int? = null,
+ val pm10Environmental: Int? = null,
+ val pm25Environmental: Int? = null,
+ val pm100Environmental: Int? = null,
+ val particles03um: Int? = null,
+ val particles05um: Int? = null,
+ val particles10um: Int? = null,
+ val particles25um: Int? = null,
+ val particles50um: Int? = null,
+ val particles100um: Int? = null,
+ val co2: Int? = null,
+ val co2Temperature: Float? = null,
+ val co2Humidity: Float? = null,
+ val formFormaldehyde: Float? = null,
+ val formHumidity: Float? = null,
+ val formTemperature: Float? = null,
+ val pm40Standard: Int? = null,
+ val particles40um: Int? = null,
+ val pmTemperature: Float? = null,
+ val pmHumidity: Float? = null,
+ val pmVocIdx: Float? = null,
+ val pmNoxIdx: Float? = null,
+ val particlesTps: Float? = null,
+)
+
+@Serializable data class PaxcountExport(val wifi: Int = 0, val ble: Int = 0, val uptime: Int = 0)
+
+@Serializable
+data class DeviceMetadataExport(
+ val firmwareVersion: String? = null,
+ val deviceStateVersion: Int? = null,
+ val canShutdown: Boolean = false,
+ val hasWifi: Boolean = false,
+ val hasBluetooth: Boolean = false,
+ val hasEthernet: Boolean = false,
+ val role: String? = null,
+ val positionFlags: Int? = null,
+ val hwModel: String? = null,
+ val hasRemoteHardware: Boolean = false,
+ val hasPKC: Boolean = false,
+ val excludedModules: Int? = null,
+ val hasXeddsa: Boolean = false,
+)
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt
new file mode 100644
index 0000000000..81196708db
--- /dev/null
+++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.domain.usecase.settings
+
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.double
+import kotlinx.serialization.json.float
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.long
+import okio.Buffer
+import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ExportNodeDatabaseUseCaseTest {
+
+ private lateinit var nodeRepository: FakeNodeRepository
+ private lateinit var useCase: ExportNodeDatabaseUseCase
+
+ @BeforeTest
+ fun setUp() {
+ nodeRepository = FakeNodeRepository()
+ useCase = ExportNodeDatabaseUseCase(nodeRepository)
+ }
+
+ private suspend fun exportJson(): JsonObject {
+ val buffer = Buffer()
+ useCase(buffer)
+ return Json.parseToJsonElement(buffer.readUtf8()).jsonObject
+ }
+
+ @Test
+ fun `empty database exports a valid document`() = runTest {
+ val root = exportJson()
+
+ assertEquals(1, root["schemaVersion"]?.jsonPrimitive?.int)
+ assertTrue(root["exportedAt"]?.jsonPrimitive?.content.orEmpty().isNotEmpty())
+ assertNull(root["myNodeNum"])
+ assertEquals(0, root["nodes"]?.jsonArray?.size)
+ }
+
+ @Test
+ fun `absent readings are omitted rather than exported as sentinels`() = runTest {
+ // A bare node: snr/rssi hold their unset sentinels, lastHeard is 0, and no metrics were ever received.
+ nodeRepository.setNodes(listOf(Node(num = 42)))
+
+ val node = exportJson()["nodes"]!!.jsonArray.single().jsonObject
+
+ assertEquals(42, node["num"]?.jsonPrimitive?.int)
+ assertFalse("snr" in node)
+ assertFalse("rssi" in node)
+ assertFalse("lastHeard" in node)
+ assertFalse("position" in node)
+ assertFalse("deviceMetrics" in node)
+ assertFalse("environmentMetrics" in node)
+ assertFalse("publicKey" in node)
+ }
+
+ @Test
+ fun `zero readings are real and survive the export`() = runTest {
+ // 0 dB SNR and 0 dBm RSSI are genuine measurements, not absence.
+ nodeRepository.setNodes(listOf(Node(num = 7, snr = 0f, rssi = 0, lastHeard = 1700000000)))
+
+ val node = exportJson()["nodes"]!!.jsonArray.single().jsonObject
+
+ assertEquals(0f, node["snr"]?.jsonPrimitive?.float)
+ assertEquals(0, node["rssi"]?.jsonPrimitive?.int)
+ assertEquals(1700000000L, node["lastHeard"]?.jsonPrimitive?.long)
+ }
+
+ @Test
+ fun `node fields map through user position metrics and key`() = runTest {
+ val key = "0123456789abcdef0123456789abcdef".encodeUtf8()
+ val node =
+ Node(
+ num = 7,
+ user =
+ User(
+ id = "!00000007",
+ long_name = "Base Camp",
+ short_name = "BASE",
+ hw_model = HardwareModel.TBEAM,
+ ),
+ position = Position(latitude_i = 450000000, longitude_i = -930000000),
+ deviceMetrics = DeviceMetrics(battery_level = 85, voltage = 3.9f),
+ publicKey = key,
+ isFavorite = true,
+ viaMqtt = true,
+ hopsAway = 2,
+ notes = "solar repeater",
+ )
+ nodeRepository.setNodes(listOf(node))
+
+ val exported = exportJson()["nodes"]!!.jsonArray.single().jsonObject
+
+ assertEquals("!00000007", exported["id"]?.jsonPrimitive?.content)
+ assertEquals("Base Camp", exported["longName"]?.jsonPrimitive?.content)
+ assertEquals("BASE", exported["shortName"]?.jsonPrimitive?.content)
+ assertEquals("TBEAM", exported["hwModel"]?.jsonPrimitive?.content)
+ assertEquals(key.base64(), exported["publicKey"]?.jsonPrimitive?.content)
+ assertTrue(exported["isFavorite"]?.jsonPrimitive?.boolean == true)
+ assertTrue(exported["viaMqtt"]?.jsonPrimitive?.boolean == true)
+ assertEquals(2, exported["hopsAway"]?.jsonPrimitive?.int)
+ assertEquals("solar repeater", exported["notes"]?.jsonPrimitive?.content)
+
+ val position = exported["position"]!!.jsonObject
+ assertEquals(45.0, position["latitude"]!!.jsonPrimitive.double, absoluteTolerance = 1e-6)
+ assertEquals(-93.0, position["longitude"]!!.jsonPrimitive.double, absoluteTolerance = 1e-6)
+
+ val metrics = exported["deviceMetrics"]!!.jsonObject
+ assertEquals(85, metrics["batteryLevel"]?.jsonPrimitive?.int)
+ assertEquals(3.9f, metrics["voltage"]?.jsonPrimitive?.float)
+ }
+
+ @Test
+ fun `node numbers export as unsigned`() = runTest {
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = -2))
+ nodeRepository.setNodes(listOf(Node(num = -1)))
+
+ val root = exportJson()
+
+ assertEquals(4294967294L, root["myNodeNum"]?.jsonPrimitive?.long)
+ val node = root["nodes"]!!.jsonArray.single().jsonObject
+ assertEquals(4294967295L, node["num"]?.jsonPrimitive?.long)
+ }
+
+ @Test
+ fun `nodes are sorted most recently heard first`() = runTest {
+ nodeRepository.setNodes(
+ listOf(Node(num = 1, lastHeard = 100), Node(num = 2, lastHeard = 300), Node(num = 3, lastHeard = 200)),
+ )
+
+ val nums = exportJson()["nodes"]!!.jsonArray.map { it.jsonObject["num"]?.jsonPrimitive?.int }
+
+ assertEquals(listOf(2, 3, 1), nums)
+ }
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 09ef2c001d..01f21d1dc8 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -599,9 +599,11 @@
<string name="expand_chart">Expand chart</string>
<string name="expanded">Expanded</string>
<string name="expires">Expires</string>
+ <!-- EXPORT -->
<string name="export_configuration">Export configuration</string>
<string name="export_data_csv">Export all packets</string>
<string name="export_gpx">Export GPX</string>
+ <string name="export_node_db">Export node database</string>
<string name="export_tak_data_package">Export TAK Data Package</string>
<string name="external_notification">External Notification</string>
<string name="external_notification_config">External Notification Config</string>
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index d0ee84876d..f1004d77d5 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -266,6 +266,7 @@ fun SettingsScreen(
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) },
+ onExportNodeDb = { settingsViewModel.saveNodeDbJson(it) },
)
ListItem(
text = stringResource(Res.string.node_layout_section_title),
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
index 1533b02fa7..0a511a8281 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
@@ -30,6 +30,7 @@ import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.resources.Res
@@ -37,12 +38,14 @@ import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.export_data_csv
+import org.meshtastic.core.resources.export_node_db
import org.meshtastic.core.resources.save_rangetest
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Output
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import kotlin.time.Instant.Companion.fromEpochMilliseconds
private val EXPORT_TIMESTAMP_FORMAT =
@@ -63,6 +66,7 @@ internal fun ColumnScope.PersistenceSettingsContent(
onSetCacheLimit: (Int) -> Unit,
nodeShortName: String,
onExportData: (android.net.Uri) -> Unit,
+ onExportNodeDb: (CommonUri) -> Unit,
) {
val timestamp =
fromEpochMilliseconds(nowMillis)
@@ -122,6 +126,20 @@ internal fun ColumnScope.PersistenceSettingsContent(
}
exportDataLauncher.launch(intent)
}
+
+ ExportNodeDbItem(nodeShortName = nodeShortName, timestamp = timestamp, onExportNodeDb = onExportNodeDb)
+}
+
+@Composable
+private fun ExportNodeDbItem(nodeShortName: String, timestamp: String, onExportNodeDb: (CommonUri) -> Unit) {
+ val exportNodeDbLauncher = rememberSaveFileLauncher { uri -> onExportNodeDb(uri) }
+ ListItem(
+ text = stringResource(Res.string.export_node_db),
+ leadingIcon = MeshtasticIcons.Output,
+ trailingIcon = null,
+ ) {
+ exportNodeDbLauncher("Meshtastic_nodedb_${nodeShortName}_$timestamp.json", "application/json")
+ }
}
@Preview(showBackground = true)
@@ -134,6 +152,7 @@ fun PersistenceSectionPreview() {
onSetCacheLimit = {},
nodeShortName = "TEST",
onExportData = {},
+ onExportNodeDb = {},
)
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index 825edefe43..10583df3e7 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -31,6 +31,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportNodeDatabaseUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.model.ConnectionState
@@ -61,6 +62,7 @@ class SettingsViewModel(
private val notificationPrefs: NotificationPrefs,
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
private val exportDataUseCase: ExportDataUseCase,
+ private val exportNodeDatabaseUseCase: ExportNodeDatabaseUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase,
private val fileService: FileService,
private val hiddenFeaturesUnlock: HiddenFeaturesUnlock,
@@ -181,6 +183,11 @@ class SettingsViewModel(
exportDataUseCase(writer, myNodeNum, filterPortnum)
}
+ /** Export the current device's node database as a JSON file at the given URI. */
+ fun saveNodeDbJson(uri: CommonUri) {
+ safeLaunch(tag = "saveNodeDbJson") { fileService.write(uri) { sink -> exportNodeDatabaseUseCase(sink) } }
+ }
+
// Node list layout preferences
val nodeListDensity = uiPrefs.nodeListDensity
val shouldShowPower = uiPrefs.shouldShowPower
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
index 1434f4cc66..4103e19d2a 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportNodeDatabaseUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.model.ConnectionState
@@ -106,6 +107,7 @@ class SettingsViewModelTest {
val uiPrefs = appPreferences.ui
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, appPreferences.meshLog)
val exportDataUseCase = ExportDataUseCase(nodeRepository, meshLogRepository)
+ val exportNodeDatabaseUseCase = ExportNodeDatabaseUseCase(nodeRepository)
viewModel =
SettingsViewModel(
@@ -119,6 +121,7 @@ class SettingsViewModelTest {
notificationPrefs = notificationPrefs,
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
exportDataUseCase = exportDataUseCase,
+ exportNodeDatabaseUseCase = exportNodeDatabaseUseCase,
isOtaCapableUseCase = isOtaCapableUseCase,
fileService = fileService,
hiddenFeaturesUnlock = HiddenFeaturesUnlock(),
@@ -326,6 +329,34 @@ class SettingsViewModelTest {
assertFalse(csvOutput.contains("Ignore me"))
}
+ @Test
+ fun `saveNodeDbJson writes node database export via file service`() = runTest {
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = 456))
+ nodeRepository.setNodes(
+ listOf(TestDataFactory.createTestNode(num = 123, longName = "Sender Node", shortName = "SN")),
+ )
+
+ val buffer = Buffer()
+ everySuspend { fileService.write(any(), any()) } calls
+ { args ->
+ val block = args.arg<suspend (BufferedSink) -> Unit>(1)
+ block(buffer)
+ true
+ }
+
+ val uri = CommonUri.parse("content://test/nodedb.json")
+ viewModel.saveNodeDbJson(uri)
+ runCurrent()
+
+ verifySuspend { fileService.write(uri, any()) }
+
+ val jsonOutput = buffer.readUtf8()
+ assertTrue(jsonOutput.contains("\"schemaVersion\": 1"))
+ assertTrue(jsonOutput.contains("\"myNodeNum\": 456"))
+ assertTrue(jsonOutput.contains("\"num\": 123"))
+ assertTrue(jsonOutput.contains("\"longName\": \"Sender Node\""))
+ }
+
@Test
fun `setDbCacheLimit updates manager`() = runTest {
viewModel.setDbCacheLimit(200)
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Dark_d19fbf1f_0.png
index e4a809e8f9..6e49d6e318 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Light_b29dc7a7_0.png
index 5daff81b02..ec16032380 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotPersistenceSection_Light_b29dc7a7_0.png differ
Served by rngit 1.5.2 - Generated in 0.26s